Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 63fca0c1b19c6b5024759962c14ee21fdeb77366


Parents : 213b889
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-16T03:20:24-05:00

feat(components): improve AboutPage, CallPage, DebugLogsPage, and SettingsPage with improved layout, error handling, and clipboard functionality; add tests for environment path fallbacks and log copying

Changes
Diff

diff --git a/meshchatx/src/frontend/components/about/AboutPage.vue b/meshchatx/src/frontend/components/about/AboutPage.vue
index 0e73d3ba..8bd572f9 100644
--- a/meshchatx/src/frontend/components/about/AboutPage.vue
+++ b/meshchatx/src/frontend/components/about/AboutPage.vue
@@ -1,9 +1,9 @@
<template>
<div class="flex flex-col flex-1 overflow-hidden min-w-0 bg-slate-50 dark:bg-zinc-950">
<div
- class="flex-1 overflow-y-auto w-full px-3 sm:px-5 md:px-5 lg:px-8 py-4 sm:py-6 text-gray-900 dark:text-zinc-100"
+ class="flex-1 overflow-y-auto overflow-x-hidden w-full min-w-0 px-3 sm:px-5 md:px-5 lg:px-8 py-4 sm:py-6 text-gray-900 dark:text-zinc-100"
>
- <div class="space-y-0 w-full max-w-4xl mx-auto pb-16 sm:pb-24">
+ <div class="space-y-0 w-full max-w-4xl mx-auto pb-16 sm:pb-24 min-w-0">
<div v-if="appInfo" class="about-section">
<div class="flex flex-col gap-8 lg:flex-row lg:items-center">
<!-- Logo & Title -->
@@ -271,13 +271,13 @@
<v-icon icon="mdi-server" size="14"></v-icon>
Environment Information
</div>
- <div class="grid gap-8 sm:grid-cols-2 lg:grid-cols-3 text-sm">
+ <div class="grid gap-8 sm:grid-cols-2 lg:grid-cols-3 text-sm min-w-0">
<div>
<div class="glass-label !text-[10px] mb-2 opacity-50">Reticulum Config</div>
<div
class="monospace-field !bg-zinc-50 dark:!bg-zinc-950 break-all text-[11px] !p-3 rounded-xl border border-zinc-100 dark:border-zinc-800"
>
- {{ appInfo.reticulum_config_path }}
+ {{ appInfo.reticulum_config_path || "unknown" }}
</div>
<button
v-if="isElectron"
@@ -293,7 +293,7 @@
<div
class="monospace-field !bg-zinc-50 dark:!bg-zinc-950 break-all text-[11px] !p-3 rounded-xl border border-zinc-100 dark:border-zinc-800"
>
- {{ appInfo.database_path }}
+ {{ appInfo.database_path || "unknown" }}
</div>
<button
v-if="isElectron"
@@ -390,8 +390,8 @@
<v-icon icon="mdi-link-variant" size="14"></v-icon>
Dependency Chain
</div>
- <div class="grid grid-cols-1 lg:grid-cols-2 gap-12 relative">
- <div class="flex flex-col space-y-8">
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12 relative min-w-0">
+ <div class="flex flex-col space-y-8 min-w-0">
<div class="flex items-center gap-5">
<div
class="w-12 h-12 rounded-2xl bg-blue-500/10 flex items-center justify-center border border-blue-500/20 shadow-sm"
@@ -463,8 +463,8 @@
<div class="text-sm font-black text-gray-900 dark:text-white leading-tight">
Reticulum Network Stack
</div>
- <div class="flex items-center gap-2 mt-1">
- <div class="text-xs font-mono font-bold text-gray-400">
+ <div class="flex flex-wrap items-center gap-2 mt-1 min-w-0">
+ <div class="text-xs font-mono font-bold text-gray-400 shrink-0">
v{{ appInfo.rns_version }}
</div>
<div
@@ -473,7 +473,7 @@
? 'bg-blue-500/10 text-blue-500 border-blue-500/20'
: 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20',
]"
- class="text-[8px] font-black uppercase tracking-wider px-1.5 py-0.5 rounded border"
+ class="text-[8px] font-black uppercase tracking-wider px-1.5 py-0.5 rounded border max-w-full break-words"
>
{{
appInfo.is_connected_to_shared_instance
@@ -486,9 +486,9 @@
</div>
</div>
- <div class="space-y-8">
+ <div class="space-y-8 min-w-0">
<div
- class="py-4 sm:p-5 border-t border-gray-200/60 dark:border-zinc-800/80 sm:border sm:rounded-2xl sm:bg-black/[0.02] dark:sm:bg-white/[0.02]"
+ class="py-4 sm:p-5 border-t border-gray-200/60 dark:border-zinc-800/80 sm:border sm:rounded-2xl sm:bg-black/[0.02] dark:sm:bg-white/[0.02] min-w-0"
>
<div
class="text-[10px] font-black text-gray-400 dark:text-zinc-600 uppercase tracking-[0.2em] mb-4"
@@ -962,11 +962,36 @@ export default {
}
},
environmentInfo() {
+ const ua = typeof navigator !== "undefined" ? navigator.userAgent || "" : "";
+ let platform = typeof navigator !== "undefined" && navigator.platform ? navigator.platform : "";
+ if (
+ !platform &&
+ typeof navigator !== "undefined" &&
+ navigator.userAgentData &&
+ navigator.userAgentData.platform
+ ) {
+ platform = navigator.userAgentData.platform;
+ }
+ if (!platform && /Android/i.test(ua)) {
+ platform = "Android";
+ }
+ if (!platform && this.appInfo && this.appInfo.host_platform) {
+ platform = this.appInfo.host_platform;
+ }
+ if (!platform) {
+ platform = "unknown";
+ }
+ const language =
+ typeof navigator !== "undefined" && navigator.language
+ ? navigator.language
+ : typeof navigator !== "undefined" && navigator.languages && navigator.languages[0]
+ ? navigator.languages[0]
+ : "unknown";
return {
- platform: navigator?.platform || "unknown",
- language: navigator?.language || "unknown",
- userAgent: navigator?.userAgent || "unknown",
- backendUrl: window?.location?.origin || "unknown",
+ platform,
+ language,
+ userAgent: ua || "unknown",
+ backendUrl: typeof window !== "undefined" && window.location ? window.location.origin : "unknown",
};
},
},

diff --git a/meshchatx/src/frontend/components/call/CallPage.vue b/meshchatx/src/frontend/components/call/CallPage.vue
index 87f403d5..cb236039 100644
--- a/meshchatx/src/frontend/components/call/CallPage.vue
+++ b/meshchatx/src/frontend/components/call/CallPage.vue
@@ -2411,15 +2411,14 @@ export default {
},
getMediaDevicesApi() {
const mediaDevices = navigator?.mediaDevices;
- if (
- !mediaDevices ||
- typeof mediaDevices.getUserMedia !== "function" ||
- typeof mediaDevices.enumerateDevices !== "function"
- ) {
+ if (!mediaDevices || typeof mediaDevices.getUserMedia !== "function") {
return null;
}
return mediaDevices;
},
+ hasEnumerateDevicesApi(mediaDevices) {
+ return Boolean(mediaDevices && typeof mediaDevices.enumerateDevices === "function");
+ },
getAudioContextConstructor() {
return window.AudioContext || window.webkitAudioContext || null;
},
@@ -2501,17 +2500,10 @@ export default {
return;
}
await this.refreshAudioDevices();
- const hasInputDevices = (this.audioInputDevices || []).length > 0;
- if (!hasInputDevices) {
- await this.disableWebAudioBridgeWithError(
- "call.no_audio_input_found",
- new Error("No audio input devices detected"),
- "start-no-input-devices"
- );
- return;
- }
-
- const validDeviceIds = new Set((this.audioInputDevices || []).map((d) => d.deviceId));
+ const canEnumerate = this.hasEnumerateDevicesApi(mediaDevices);
+ const validDeviceIds = canEnumerate
+ ? new Set((this.audioInputDevices || []).map((d) => d.deviceId))
+ : new Set();
const hasSelectedDevice = this.selectedAudioInputId && validDeviceIds.has(this.selectedAudioInputId);
const constraints = hasSelectedDevice
? { audio: { deviceId: { exact: this.selectedAudioInputId } } }
@@ -2583,11 +2575,13 @@ export default {
if (!mediaDevices) {
throw new Error("navigator.mediaDevices is unavailable");
}
- const devices = await mediaDevices.enumerateDevices();
- const hasAudioInput = devices.some((d) => d.kind === "audioinput");
- if (devices.length > 0 && !hasAudioInput) {
- ToastUtils.error(this.$t("call.no_audio_input_found"));
- return false;
+ if (this.hasEnumerateDevicesApi(mediaDevices)) {
+ const devices = await mediaDevices.enumerateDevices();
+ const hasAudioInput = devices.some((d) => d.kind === "audioinput");
+ if (devices.length > 0 && !hasAudioInput) {
+ ToastUtils.error(this.$t("call.no_audio_input_found"));
+ return false;
+ }
}
const constraints = this.selectedAudioInputId
@@ -2617,6 +2611,11 @@ export default {
this.audioOutputDevices = [];
return;
}
+ if (!this.hasEnumerateDevicesApi(mediaDevices)) {
+ this.audioInputDevices = [];
+ this.audioOutputDevices = [];
+ return;
+ }
const devices = await mediaDevices.enumerateDevices();
this.audioInputDevices = devices.filter((d) => d.kind === "audioinput");
this.audioOutputDevices = devices.filter((d) => d.kind === "audiooutput");

diff --git a/meshchatx/src/frontend/components/debug/DebugLogsPage.vue b/meshchatx/src/frontend/components/debug/DebugLogsPage.vue
index 88ec0748..7ade180b 100644
--- a/meshchatx/src/frontend/components/debug/DebugLogsPage.vue
+++ b/meshchatx/src/frontend/components/debug/DebugLogsPage.vue
@@ -1,7 +1,7 @@
<template>
<div class="flex flex-col flex-1 overflow-hidden min-w-0 bg-slate-50 dark:bg-zinc-950">
<div
- class="flex flex-col h-full overflow-hidden w-full px-4 md:px-5 lg:px-8 py-6 pb-[max(1.5rem,env(safe-area-inset-bottom))]"
+ class="flex flex-col h-full overflow-hidden w-full px-3 sm:px-4 md:px-5 lg:px-8 py-3 sm:py-6 pb-[max(1.5rem,env(safe-area-inset-bottom))]"
>
<div class="flex flex-col mb-4 w-full max-w-6xl mx-auto space-y-4 min-w-0">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between min-w-0">
@@ -131,7 +131,7 @@
<div class="flex-1 overflow-hidden glass-card max-w-6xl mx-auto w-full p-0 flex flex-col rounded-sm">
<div
v-if="activeTab === 'logs'"
- class="flex-1 overflow-auto p-4 font-mono text-[10px] sm:text-xs leading-relaxed select-text bg-white dark:bg-zinc-950"
+ class="flex-1 overflow-auto p-3 sm:p-4 font-mono text-xs leading-relaxed select-text bg-white dark:bg-zinc-950"
>
<div v-if="logs.length === 0" class="text-gray-500 italic text-center py-10">
{{ loading ? $t("debug.loading_logs") : $t("debug.no_logs") }}
@@ -139,14 +139,16 @@
<div
v-for="(log, index) in logs"
:key="index"
- class="border-b border-gray-100 dark:border-zinc-900 py-1 flex gap-3 hover:bg-gray-50 dark:hover:bg-zinc-900/50"
+ class="border-b border-gray-100 dark:border-zinc-900 py-1.5 flex gap-2 sm:gap-3 hover:bg-gray-50 dark:hover:bg-zinc-900/50 cursor-copy"
:class="{ 'bg-red-50/30 dark:bg-red-900/10': log.is_anomaly }"
+ title="Tap to copy this log entry"
+ @click="copyLogLine(log)"
>
<span class="text-gray-400 shrink-0">{{ formatTime(log.timestamp) }}</span>
<span :class="levelClass(log.level)" class="w-12 shrink-0 font-bold uppercase">{{
log.level
}}</span>
- <span class="text-blue-500 shrink-0 w-24 overflow-hidden text-ellipsis italic"
+ <span class="text-blue-500 shrink-0 w-20 sm:w-24 overflow-hidden text-ellipsis italic"
>[{{ log.module }}]</span
>
<span class="text-gray-800 dark:text-gray-200 break-words flex-1">
@@ -164,7 +166,7 @@
<div
v-else
- class="flex-1 overflow-auto p-4 font-mono text-[10px] sm:text-xs leading-relaxed select-text bg-white dark:bg-zinc-950"
+ class="flex-1 overflow-auto p-3 sm:p-4 font-mono text-xs leading-relaxed select-text bg-white dark:bg-zinc-950"
>
<div v-if="accessAttempts.length === 0" class="text-gray-500 italic text-center py-10">
{{ accessLoading ? $t("debug.loading_access") : $t("debug.no_access") }}
@@ -172,9 +174,11 @@
<div
v-for="row in accessAttempts"
:key="row.id"
- class="border-b border-gray-100 dark:border-zinc-900 py-2 flex flex-col gap-1 hover:bg-gray-50 dark:hover:bg-zinc-900/50"
+ class="border-b border-gray-100 dark:border-zinc-900 py-2 flex flex-col gap-1 hover:bg-gray-50 dark:hover:bg-zinc-900/50 cursor-copy"
+ title="Tap to copy this access entry"
+ @click="copyAccessLine(row)"
>
- <div class="flex flex-wrap gap-x-3 gap-y-1">
+ <div class="flex flex-wrap gap-x-3 gap-y-1 items-center">
<span class="text-gray-400 shrink-0">{{ formatTime(row.created_at) }}</span>
<span class="text-amber-600 dark:text-amber-400 font-semibold">{{ row.outcome }}</span>
<span class="text-cyan-600 dark:text-cyan-400">{{ row.method }} {{ row.path }}</span>
@@ -441,6 +445,32 @@ export default {
}
}
},
+ async copyLogLine(log) {
+ const line = `${this.formatTime(log.timestamp)} [${log.level}] [${log.module}] ${log.message}${log.is_anomaly ? " [ANOMALY:" + (log.anomaly_type || "unknown") + "]" : ""}`;
+ try {
+ await navigator.clipboard.writeText(line);
+ ToastUtils.success(this.$t("debug.logs_copied"));
+ } catch {
+ ToastUtils.error(this.$t("debug.failed_copy_logs"));
+ }
+ },
+ async copyAccessLine(row) {
+ const line = [
+ this.formatTime(row.created_at),
+ row.outcome,
+ row.method,
+ row.path,
+ row.client_ip,
+ row.user_agent || "",
+ row.detail || "",
+ ].join(" | ");
+ try {
+ await navigator.clipboard.writeText(line);
+ ToastUtils.success(this.$t("debug.access_copied"));
+ } catch {
+ ToastUtils.error(this.$t("debug.failed_copy_access"));
+ }
+ },
},
};
</script>

diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index ef84fb18..754fd406 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -4727,7 +4727,7 @@ export default {
// alert if failed to start recording
if (!this.isRecordingAudioAttachment) {
- DialogUtils.alert(this.$t("messages.failed_start_recording"));
+ DialogUtils.alert(this.buildAudioRecordingFailureMessage());
}
break;
@@ -4749,7 +4749,7 @@ export default {
// alert if failed to start recording
if (!this.isRecordingAudioAttachment) {
- DialogUtils.alert(this.$t("messages.failed_start_recording"));
+ DialogUtils.alert(this.buildAudioRecordingFailureMessage());
}
break;
@@ -4843,6 +4843,15 @@ export default {
// remove audio
this.newMessageAudio = null;
},
+ buildAudioRecordingFailureMessage() {
+ if (!navigator?.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== "function") {
+ return `${this.$t("messages.failed_start_recording")} (microphone API unavailable in this webview context)`;
+ }
+ if (typeof MediaRecorder !== "function") {
+ return `${this.$t("messages.failed_start_recording")} (MediaRecorder not supported on this device)`;
+ }
+ return this.$t("messages.failed_start_recording");
+ },
removeFileAttachment: function (file) {
this.newMessageFiles = this.newMessageFiles.filter((newMessageFile) => {
return newMessageFile !== file;

diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index b8e16265..d7fcfb86 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -3,8 +3,8 @@
v-if="config"
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
>
- <div class="flex-1 overflow-y-auto w-full px-3 sm:px-5 md:px-5 lg:px-8 py-4 sm:py-6">
- <div class="space-y-0 w-full max-w-6xl xl:max-w-7xl 2xl:max-w-[90rem] mx-auto">
+ <div class="flex-1 overflow-y-auto overflow-x-hidden w-full min-w-0 px-3 sm:px-5 md:px-5 lg:px-8 py-4 sm:py-6">
+ <div class="space-y-0 w-full max-w-6xl xl:max-w-7xl 2xl:max-w-[90rem] mx-auto min-w-0">
<div class="settings-section settings-section--hero">
<div class="flex flex-col lg:flex-row lg:items-center gap-4">
<div class="flex-1 space-y-1">
@@ -98,9 +98,9 @@
<!-- search bar -->
<div
- class="sticky top-0 z-10 py-3 sm:py-4 -mx-3 sm:mx-0 px-3 sm:px-0 mb-2 border-b border-gray-200/50 dark:border-zinc-800/50 bg-transparent"
+ class="sticky top-0 z-10 py-3 sm:py-4 mb-2 border-b border-gray-200/50 dark:border-zinc-800/50 bg-transparent min-w-0"
>
- <div class="relative w-full max-w-6xl xl:max-w-7xl 2xl:max-w-[90rem] mx-auto">
+ <div class="relative w-full max-w-6xl xl:max-w-7xl 2xl:max-w-[90rem] mx-auto min-w-0 px-0">
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<MaterialDesignIcon icon-name="magnify" class="size-5 text-gray-400" />
</div>

diff --git a/tests/frontend/AboutPage.test.js b/tests/frontend/AboutPage.test.js
index 2626ca33..e68b0da7 100644
--- a/tests/frontend/AboutPage.test.js
+++ b/tests/frontend/AboutPage.test.js
@@ -271,4 +271,35 @@ describe("AboutPage.vue", () => {
expect(wrapper.text()).toContain("Free Space");
expect(wrapper.text()).toContain("0 Bytes");
});
+
+ it("shows unknown fallbacks for missing environment paths", async () => {
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/app/info")
+ return Promise.resolve({
+ data: {
+ app_info: {
+ version: "1.0.0",
+ python_version: "3.11.0",
+ lxmf_version: "0.2.0",
+ rns_version: "0.1.0",
+ reticulum_config_path: null,
+ database_path: null,
+ },
+ },
+ });
+ if (url === "/api/v1/config") return Promise.resolve({ data: { config: {} } });
+ if (url === "/api/v1/database/health") return Promise.resolve({ data: { database: {} } });
+ if (url === "/api/v1/database/snapshots") return Promise.resolve({ data: [] });
+ return Promise.reject(new Error("Not found"));
+ });
+
+ const wrapper = mountAboutPage();
+ await vi.runOnlyPendingTimers();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.text()).toContain("Reticulum Config");
+ expect(wrapper.text()).toContain("Database Path");
+ expect(wrapper.text()).toContain("unknown");
+ });
});

diff --git a/tests/frontend/DebugLogsPage.test.js b/tests/frontend/DebugLogsPage.test.js
index 15649400..dfc549cc 100644
--- a/tests/frontend/DebugLogsPage.test.js
+++ b/tests/frontend/DebugLogsPage.test.js
@@ -10,6 +10,11 @@ window.api = {
describe("DebugLogsPage.vue", () => {
beforeEach(() => {
vi.clearAllMocks();
+ Object.assign(navigator, {
+ clipboard: {
+ writeText: vi.fn().mockResolvedValue(undefined),
+ },
+ });
});
it("fetches and displays logs", async () => {
@@ -44,7 +49,7 @@ describe("DebugLogsPage.vue", () => {
await new Promise((resolve) => setTimeout(resolve, 10));
await wrapper.vm.$nextTick();
- const container = wrapper.find(".flex-1.overflow-auto.p-4.font-mono");
+ const container = wrapper.find(".flex-1.overflow-auto.font-mono");
const logRows = container.findAll(".border-b");
expect(logRows.length).toBe(2);
expect(wrapper.text()).toContain("Hello");
@@ -160,4 +165,37 @@ describe("DebugLogsPage.vue", () => {
expect(wrapper.text()).toContain("failed_password");
expect(wrapper.text()).toContain("10.0.0.1");
});
+
+ it("copies a single log line when tapping an entry", async () => {
+ const mockLogs = [
+ { timestamp: Date.now() / 1000, level: "ERROR", module: "web_protocol", message: "Boom", is_anomaly: 0 },
+ ];
+
+ window.api.get.mockResolvedValue({
+ data: {
+ logs: mockLogs,
+ total: 1,
+ limit: 100,
+ offset: 0,
+ },
+ });
+
+ const wrapper = mount(DebugLogsPage, {
+ global: {
+ stubs: ["MaterialDesignIcon"],
+ mocks: { $t: (key) => key },
+ },
+ });
+
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ await wrapper.vm.$nextTick();
+
+ const row = wrapper.findAll(".border-b").find((r) => r.text().includes("Boom"));
+ expect(row).toBeTruthy();
+ await row.trigger("click");
+
+ expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1);
+ expect(navigator.clipboard.writeText.mock.calls[0][0]).toContain("[ERROR]");
+ expect(navigator.clipboard.writeText.mock.calls[0][0]).toContain("Boom");
+ });
});


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────